Skip to content

fix(types): a link:/file: install's refusal states the LIMIT, not a false install remedy - #17030

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-15045-link-install-diagnostic-accuracy
Sep 9, 2026
Merged

fix(types): a link:/file: install's refusal states the LIMIT, not a false install remedy#17030
os-project-manager merged 2 commits into
mainfrom
claude/issue-15045-link-install-diagnostic-accuracy

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #15045

Clause-②: no

The link: / file: sub-case #14278 deliberately left standing was refusing correctly and explaining itself wrongly. This PR changes only what the refusal SAYS. The refusal, its kind, its code, its exit path and the finder's accept set are byte-for-byte what they were.

Not built here: the card's other route — the second verification axis (realpath(node_modules/KEY) compared against the declared location) — which would make these installs LOAD. That relaxes a published accept set and is a contract decision, not a diagnostic one. The card stays open for it.

The failure, driven

A host app declaring {"dependencies": {"linked-other": "link:../elsewhere"}}, node_modules/linked-other a real symlink to a sibling directory whose manifest is named @fixture/some-other-name, ESM-only exports. hostRequire.resolve throws ERR_PACKAGE_PATH_NOT_EXPORTED (measured), so the #14041 fallback decides.

BEFORE — verbatim:

Cannot find module 'linked-other': the host app DECLARES it (dependencies: "link:../elsewhere") but it could not be resolved.
  host app: /tmp/os-link-mismatch-imXEMJ/app

  This is an INSTALL problem, not a declaration problem — the declaration is
  already there, so re-reading the package.json will not help. Check:
    • dependencies never installed, or installed before the declaration was added → run `pnpm install` in /tmp/os-link-mismatch-imXEMJ/app
    • a production prune / filtered deploy dropped it (devDependencies and
      optionalDependencies go first)
    • it IS installed but its "main"/"exports" points at a dist that was never built
  (resolver: No "exports" main defined in /tmp/os-link-mismatch-imXEMJ/app/node_modules/linked-other/package.json)

All three prescribed checks are measurably false for this shape. The finder had just read the manifest at node_modules/linked-other, so: the package is on disk (not "never installed"), it was not pruned, and its import target exists (the dist IS built). The operator runs pnpm install, nothing changes, and goes hunting for a build that is not broken.

AFTER — same input, same fixture:

Cannot load module 'linked-other': the host app DECLARES it (dependencies: "link:../elsewhere"), a package IS installed at that key, and this ESM fallback cannot confirm it is the declared one.
  host app: /tmp/os-link-mismatch-COkaxL/app
  installed at: /tmp/os-link-mismatch-COkaxL/app/node_modules/linked-other
  its package.json is named: "@fixture/some-other-name"
  this finder expected: "linked-other"

  This is NOT an install problem, and NOT a declaration problem — the package is
  on disk and the declaration is right, so re-running `pnpm install`, un-pruning a
  deploy and rebuilding a dist all change nothing here.
  What it IS: a "link:" / "file:" declaration names a LOCATION, not a package. The
  manifest at the other end may carry any name, and the specifier holds none for
  this finder to expect, so the KEY is all it has to check against. A git or
  tarball URL (github:owner/repo, https://.../pkg.tgz) names no on-disk location
  either and lands here the same way.
  The refusal is deliberate: this fallback stays strictly tighter than the
  CommonJS resolution it backs up, and will not load a directory it cannot tie to
  the declaration. Only a package publishing no `require` condition reaches it at
  all, so nothing that loads today is affected either way.
  What DOES change it — make the two names AGREE, from whichever end you own:
    • declare the linked package under its own name: key "@fixture/some-other-name",
      pointing at the same location, and import it under that name
    • or set the linked package's own "name" to "linked-other", if that
      directory is yours to edit
  Either way the key becomes the expectation this finder checks, and the load
  succeeds through this same fallback.
  (resolver: No "exports" main defined in ...)

Every claim in the new message was measured, not reasoned. Both closing remedies were driven and both LOAD (pinned as tests): renaming the key to the linked manifest's name, and renaming the linked manifest to the key.

⛔ One remedy was deliberately removed during drafting rather than printed: "have the package publish a require condition." It does make the load succeed — and that is the problem. A dual build resolves through CommonJS, so #13330's condition re-decision runs instead, packageRootOf fails to recognise the differently-named root for exactly the reason this finder does, and ?? resolved hands back the require build. The operator gets a load plus the second-instance split #13330 exists to close, silently. That is pinned as today's behaviour by the #15044 suite (BOUNDARY: a link: target whose manifest names something else keeps today's load), which this PR leaves untouched and green. A remedy the runtime honours while making things quietly worse is not one worth printing.

The refusal did NOT move

evidence
same kind hostImportFailureKind(err) === 'declared-unresolvable' — pinned
same code err.code === 'MODULE_NOT_FOUND' — pinned
same exit path the new branch throws hostImportError('declared-unresolvable', …, cause), the identical call the fall-through below it makes
accept set unchanged negative control: a link: install whose manifest MATCHES the key still loads, silently
no leak into the alias leg a plain range ^1.0.0 with a mismatched directory still gets the INSTALL wording
no leak into the alias leg npm:@fixture/declared@1 holding @fixture/installed still gets the INSTALL wording
the absence split is real link: declared with NOTHING installed still gets the INSTALL wording

The predicate is on the declaration, never on the on-disk shape: an unrecognised specifier spelling falls out as "the key is a promise" and keeps today's wording — the conservative direction, matching how ALIAS_DECLARATION_PROTOCOLS already defaults.

Ablation — the pins can fail

Neutralising declarationNamesNoPackage to return false restores the pre-fix behaviour exactly.

Subject resolution: node.test.ts imports ./node.js, a same-package relative specifier vitest resolves to src/node.ts, so the ablation exercises source directly — no dist/ leg is involved.

Changeset — MEASURED, not guessed

@objectstack/types publishes and this is operator-visible text, so skip-changeset was tested rather than assumed. Built, then npm pack --dry-run --json, then every one of the 16 packed paths searched:

needle packed files hit
names a LOCATION, not a package (this PR's new string) 2 — dist/node.js, dist/node.mjs
This is an INSTALL problem, not a declaration problem (control, a pre-existing published string) 2 — the same two files

The control fires, so the probe works; the needle is present, so this ships. Changeset written: @objectstack/types: patch.

Clause ② — re-derived from this diff

  • Accept set: unchanged, per the table above and the ablation's green set.
  • Published surface: not widened. Nothing new is exported; the new fallback outcome member lives on the module-local DeclaredCjsResolveFallback type and HostImportFailureKind still has exactly its three members. The only occurrence of a new identifier in the emitted .d.ts is a {@link unverifiableLocationMessage} inside the JSDoc of an already-exported type — read out at dist/node.d.ts:128, documentation prose, not a declaration. check:dts-closure and check:published-files both green.

Minting a fourth HostImportFailureKind for this sub-case was considered and rejected: it would widen a published union for a wording fix. The trade is recorded in the kind's own docblock, including its cost — a consumer that re-words this kind LOCALLY instead of deferring to err.message still prints its own install remedy here (the #14270 class). See acceptance notes.

Verification

All at final commit 8e79a6bb3a.

  • pnpm --filter @objectstack/types test19 files, 584 tests passed
  • pnpm --filter @objectstack/types typecheck — exit 0. It really does compile the test file: tsc --listFiles | grep -c src/node.test.ts = 1, so this is a measurement, not an assumption.
  • pnpm --filter '@objectstack/types^...' build — exit 0 (needed: an unbuilt packages/spec dist reddens an unrelated pre-existing test in this suite; the first run reproduced exactly that and it is not from this diff)
  • gate families derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after a fetch (no new families), reconciled with --ran: 56 derived, 56 run, 0 UNRUN
  • 53 of 56 exit 0. Three exit 3 = PREREQUISITE NOT MET, which those gates print as "NOT MEASURED — neither a pass nor a failure": check:dual-build-cjs-loads and check:type-check-debt need a whole-workspace build (CI's lint.yml builds the closure first). check:lean-entry-closure was the third — its prerequisite WAS met locally by building @objectstack/objectql, after which it is a real green: "2 published condition(s) measured from a real load."
  • pnpm lint (eslint . --no-inline-config) — the full repo-scoped union, not a narrowing: 6394 files, 0 errors, 0 warnings, exit 0, re-run at 8e79a6bb3a.
  • control characters: grep -naP over both touched source files returns nothing; pnpm check:nul-bytes green.

Docs drift — audited, no page edited

The bot listed 13 hand-written pages plus 1 release-owned (content/docs/releases/v17.mdx, read-only — audited, never edited from a code PR).

Same tree, verified per file. ⛔ The bot's own footer says its checkout carried uncommitted changes, so its sha is not a pin. Instead all 14 paths were compared blob-for-blob between this worktree and 16aeaf481c92: 14/14 identical, 0 differing, 0 missing. These are the pages the bot read.

Every row is a wrong row, and each was read out rather than counted. All 14 are anchored on string literals, not symbolsnode_modules (a literal in declaredCjsResolveFallback) and package.json (literals in hostInstalledPackageDir and manifestNameAt). All 27 anchor occurrences across the 14 pages were printed and read. They are: scaffold directory trees (examples, your-first-project, plugins/development, plugins/index, kernel/plugin-spec, objectql/schema), the engines field (troubleshooting), oclif plugin config (plugins/index), the emitted manifest's dependencies (cli, upgrading, kernel/index), a wildcard-exclusion list (cli), a CHANGELOG read path (upgrading), the published spec schemas (skills-reference), and — on the release-owned page — @objectstack/cli's sealed exports map from #15325. None describes the host importer, its failure kinds, or its refusal text. A grep for host-importer terms across all 14 returns zero, with the same pattern firing 56 times in packages/types/src/node.ts as its control.

The one row that came close, and why it still stands. content/docs/deployment/tenancy-modes.mdx is the only page in all of content/ that quotes host-importer operator text. It reproduces formatOrganizationsAbsentFatal plus the undeclared arm of formatOrganizationsInstallRemedy ("declare it in the app's package.json and install"), and adds one sentence about the other arm: "If the package is already declared but the install is broken, the message says so and tells you to repair the install rather than re-read package.json." Not falsified, on two counts: this PR does not touch formatOrganizationsInstallRemedy, so the org path's rendered remedy is byte-identical; and that sentence is scoped to "the install is broken", which is precisely the antecedent this sub-case does not satisfy. It is now mildly incomplete rather than wrong — a third shape exists (declared, installed, not broken, still refused) that the page does not mention — and that incompleteness is the same consequence recorded in the acceptance notes below.

The sweep the bot structurally cannot run. Its disclosed input-vs-emitter blind spot means a page quoting the OLD refusal wording would name the words, not declaredCjsResolveFallback, and could never appear in its list. Swept content/ directly for the old message's distinctive strings:

needle pages in content/
This is an INSTALL problem 0
re-reading the package.json will not help 0
dependencies never installed 0
a production prune 0
points at a dist that was never built 0
declared-unresolvable / declared-no-loadable-entry 0
could not be resolved 1 — ui/forms.mdx, read out: a REST row for 500 LOOKUP_TARGET_MISSING, about a lookup's target object. Unrelated.
Cannot find module ' 1 — api/client-sdk.mdx, read out: a note that SDK imports on that page used to red with TS2307. Unrelated.

Two further sweeps — link: / file: / alias-shaped prose, and host-importer / declaration-gate prose — return zero pages across all of content/.

Controls, so every zero above is a reading and not a silent miss. The sweep machinery reaches content/ (organization wall is INACTIVE → 1 hit, tenancy-modes.mdx:237). Both zero-returning patterns fire where their subject lives: 2 files each under packages/types/src/.

What this did and did not answer. It answers that no hand-written page states, quotes or paraphrases the wording this PR changes, and that no page describes what a failed link: / file: load tells an operator. It does not answer the bot's two disclosed blind spots in general — the 156 unreachable route-ledger rows, and input-stated rules on pages sharing no token with their emitter. The second one is the one that matters on a message diff, which is why it was swept by hand above rather than delegated. ⇒ No documentation page is falsified by this PR, so no page is edited and the declared file surface does not widen.

验收备注


Generated by Claude Code

…e install remedy

`createHostImporter`'s ESM-only fallback finder refuses a `link:` / `file:`
install whose linked manifest names something other than the declaration key —
correctly, and deliberately, since a location specifier carries no package name
to expect and accepting the directory anyway would trade a wrong REMEDY for a
wrong LOAD. It reported that refusal with the `declared-unresolvable` INSTALL
wording, every remedy of which is measurably false for this shape: the finder
had just read the manifest at `node_modules/<key>`, so the package is on disk,
was not pruned, and its `import` target exists.

The refusal is unchanged — same kind, same `MODULE_NOT_FOUND`, same exit path,
same accept set. Only the words change: the message now states the directory it
consulted, the name found there, the name expected, why a location specifier
leaves it only the key, and the remedy that works (make the two names agree,
from either end — both pinned as loading).

The second verification axis the card also proposes — comparing
`realpath(node_modules/<key>)` against the declared location, which would make
these installs LOAD — is deliberately not built here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/types, touching 10 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via node_modules (literal, a string literal in declaredCjsResolveFallback))
  • content/docs/deployment/cli.mdx (via node_modules (literal, a string literal in declaredCjsResolveFallback), package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))
  • content/docs/deployment/self-hosting.mdx (via package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))
  • content/docs/deployment/tenancy-modes.mdx (via package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))
  • content/docs/deployment/troubleshooting.mdx (via package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))
  • content/docs/getting-started/examples.mdx (via package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))
  • content/docs/getting-started/your-first-project.mdx (via package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))
  • content/docs/plugins/development.mdx (via package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))
  • content/docs/plugins/index.mdx (via package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))
  • content/docs/protocol/kernel/index.mdx (via package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))
  • content/docs/protocol/kernel/plugin-spec.mdx (via package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))
  • content/docs/protocol/objectql/schema.mdx (via package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))
  • content/docs/upgrading.mdx (via node_modules (literal, a string literal in declaredCjsResolveFallback), package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via package.json (literal, a string literal in hostInstalledPackageDir; a string literal in manifestNameAt))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d127f9babce9d483ac4c053194360968e1c7ba0apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 16aeaf481c922df718aea311ccb007adae2bbafb — the merge of head 8e79a6bb3ad66986f50983fa734922f2170c03da into base d127f9babce9d483ac4c053194360968e1c7ba0a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 16aeaf481c922df718aea311ccb007adae2bbafb && git checkout 16aeaf481c922df718aea311ccb007adae2bbafb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d127f9babce9d483ac4c053194360968e1c7ba0a 8e79a6bb3ad66986f50983fa734922f2170c03da && git checkout -B drift-repro d127f9babce9d483ac4c053194360968e1c7ba0a && git merge --no-ff 8e79a6bb3ad66986f50983fa734922f2170c03da

node scripts/docs-audit/affected-docs.mjs --json d127f9babce9d483ac4c053194360968e1c7ba0a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d127f9babce9d483ac4c053194360968e1c7ba0a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants